feat: add project metadata to project list and view - #693
Conversation
| if !project.IsVersionControlled { | ||
| return "Not version controlled" | ||
| } | ||
| return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() |
There was a problem hiding this comment.
versionControlBranch can panic on an interface-conversion. The bare type assertion project.PersistenceSettings.(projects.GitPersistenceSettings) panics if the server ever returns IsVersionControlled: true without a Git-typed PersistenceSettings block — in SDK v2.114.1, Project.UnmarshalJSON leaves PersistenceSettings as a nil interface when the field is absent (and the two JSON fields are independent, so nothing guarantees they agree). This predates the PR, but the refactor consolidates it into a helper now called from all three output mappers, so it is a good moment to make it defensive:
| return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() | |
| if gitSettings, ok := project.PersistenceSettings.(projects.GitPersistenceSettings); ok { | |
| return gitSettings.DefaultBranch() | |
| } | |
| return "" |
There was a problem hiding this comment.
Actioned in a1d4f91, with your suggested shape (comma-ok, empty string fallback).
Confirmed it's reachable rather than theoretical: PersistenceSettings carries no validate:"required" tag, so Project.UnmarshalJSON accepts a payload with IsVersionControlled: true and no settings block, leaving the interface nil. The regression test project view does not panic when a version controlled project has no git settings (pkg/cmd/project/view/view_test.go) serves exactly that payload; I reverted versionControlBranch to the bare assertion and ran it, and it panics with interface conversion: interface is nil, not projects.GitPersistenceSettings.
One knock-on, fixed in c72af0e: the "" fallback made basic output print a bare Version control branch: line, which is the same blank-label problem you raised on the project group / lifecycle labels. That label is now skipped when the branch is empty, and the regression test asserts its absence. Table and JSON still get "" for that field — an empty cell and an empty string read fine there, and changing the JSON value to a sentinel would be a schema decision rather than part of a panic fix.
|
|
||
| // two lookups for the whole list rather than one per project, and best-effort | ||
| // as channel list is: listing still works without access to either | ||
| lifecycleMap := shared.GetLifecycleMap(client) |
There was a problem hiding this comment.
project list -f basic now makes 3 API round trips instead of 1. GetLifecycleMap and GetProjectGroupMap are fetched unconditionally before output.PrintArray, but the Basic mapper only prints p.GetName() — for basic output (common in scripting) the two /all lookups are pure wasted latency and server load. Consider checking the resolved output format first (as PrintArray does via constants.FlagOutputFormat / viper) and skipping the lookups for basic, or lazily populating the maps on first use.
There was a problem hiding this comment.
Actioned in 6488338.
PrintArray and PrintResource each had their own copy of the flag-then-viper format resolution, so rather than re-implement it a third time in listRun I extracted it as output.ResolveOutputFormat(cmd) and made both printers call it. listRun now only fetches the two maps when the resolved format isn't basic, so project list -f basic is back to one round trip. Went with the up-front check over lazy population because the maps are read from inside the Table/Json mappers, and lazy fill would mean either a closure with a sync.Once or a nil-map check at each of four call sites for no real gain.
Covered by outputFormat basic lists just names, without the name lookups in pkg/cmd/project/list/list_test.go: it queues only root, space and projects/all, and because the mock server has no response waiting for a fourth request the test deadlocks (and the package times out) if the lookups come back. That's how I confirmed the assertion actually bites.
Deliberately not changed: project view still does its two single-ID lookups unconditionally, because its basic output does print the project group and lifecycle names, so there's nothing to skip there.
|
|
||
| // GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a | ||
| // failed lookup yields an empty map and callers fall back to the ID. | ||
| func GetLifecycleMap(octopus *client.Client) map[string]string { |
There was a problem hiding this comment.
This duplicates getLifecycleMap in pkg/cmd/channel/list/list.go (line 151) byte-for-byte. Since this PR promotes the pattern to a shared helper, consider migrating channel list to call it too (or placing the helper in a neutral package outside pkg/cmd/project/ so a channel command importing it does not read oddly). Two identical copies will drift — e.g. if pagination or error reporting ever changes.
There was a problem hiding this comment.
Actioned in e3f4330, taking the second of your two options.
GetLifecycleMap, GetProjectGroupMap, GetLifecycleName, GetProjectGroupName and DisplayName moved to a new neutral pkg/lookups package, and pkg/cmd/channel/list/getLifecycleMap is deleted in favour of lookups.GetLifecycleMap. That avoids a channel command importing a project command's shared package, which was the part that read oddly. pkg/cmd/project/shared keeps TenantedDeploymentMode, since that is genuinely project specific.
No new tests — this is a move plus one call-site swap, and the behaviour is covered by the existing pkg/cmd/channel/list and pkg/cmd/project/{list,view} suites, which pass.
Worth noting for later: the shared copy inherits the channel version's blind spot rather than fixing it. Lifecycles.GetAll() / ProjectGroups.GetAll() swallow the error and return an empty map, so a 403 or a transport failure is indistinguishable from "no lifecycles exist" and the caller silently shows IDs. That's now one place to fix instead of two, which was your point, but I haven't changed the semantics in this PR.
| result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(project.Name), output.Dimf("(%s)", project.Slug))) | ||
|
|
||
| // where the project sits and how it releases | ||
| result.WriteString(fmt.Sprintf("Project group: %s\n", output.Cyan(shared.DisplayName(project.ProjectGroupID, projectGroupName)))) |
There was a problem hiding this comment.
Minor: empty ID prints a blank labeled line. GetLifecycleName/GetProjectGroupName explicitly guard id == "", but when that case fires, DisplayName("", "") returns "", so basic output prints Project group: / Lifecycle: with nothing after the label (and the table shows an empty cell). If an empty ID is reachable, a placeholder like Unknown (or skipping the line) would read better.
There was a problem hiding this comment.
Actioned in e74c28d — the label is skipped rather than printed with nothing after it — but with a correction to the "if an empty ID is reachable" premise: for these two fields it isn't, at least not through project view.
Project.UnmarshalJSON in the SDK validates the decoded payload with validator.Struct, and both fields are tagged validate:"required" (v2.114.1, pkg/projects/project.go: LifecycleID line 94, ProjectGroupID line 96). So a project response with an empty LifecycleId or ProjectGroupId never deserialises. I tried to write a test for it and watched Projects.GetByIdentifier swallow the unmarshal failure and fall back to GET /api/Spaces-1/projects?partialName=Projects-22 — the formatter is never reached. So the guard is pure defence in depth, and I've left it untested rather than build a fake search-fallback response to exercise dead code.
I picked skipping the line over an Unknown placeholder because the non-empty-ID case already degrades gracefully (DisplayName falls back to the ID — covered by project view falls back to IDs when the lookups fail (basic)), so the only way to reach a blank label is a payload the SDK rejects.
The blank label that is reachable is the one right below: versionControlBranch returns "" for a version-controlled project with no Git settings, which printed Version control branch: with nothing after it. Fixed the same way in c72af0e, and that one has a test.
| // footer with web URL | ||
| url := util.GenerateWebURL(opts.Host, project.SpaceID, fmt.Sprintf("projects/%s", project.GetID())) | ||
| url := webUrl(opts, project) | ||
| result.WriteString(fmt.Sprintf("View this project in Octopus Deploy: %s\n", output.Blue(url))) |
There was a problem hiding this comment.
Pre-existing quirk this refactor keeps: browser.OpenURL lives inside the Basic formatter, so octopus project view X --web -f table or -f json silently never opens the browser — the flag only works with the default basic format. Worth hoisting the opts.flags.Web.Value check into viewRun so --web behaves the same for every output format.
There was a problem hiding this comment.
Actioned in 67e3cc6, in this PR rather than a follow-up — but flagging it as the one change here that's a judgement call, since you're right that the quirk is pre-existing.
Why I kept it in: the fix is the flag check moving into viewRun, and viewRun plus that exact block of the basic formatter are already rewritten by this PR (the webUrl extraction touches the same lines). Splitting it out would mean a follow-up PR whose whole diff conflicts with this one for no isolation benefit. Easy to pull back out if you'd rather keep the PR strictly to metadata — it's a single commit at the tip, so git revert 67e3cc6 here and re-land it separately is clean.
Two things to be aware of in the new behaviour:
--webnow opens the browser before the output is written, where previously (basic only) it opened after. Nothing depends on the ordering, but it is a visible difference if you're watching a terminal.- The
browser.OpenURLerror is still discarded, now as an explicit_ =. Reporting it would changeproject view --webfrom "prints the project, quietly fails to open a browser" to something that can fail the command, which felt out of scope. Happy to surface it to stderr instead if you'd prefer.
No test: asserting on browser.OpenURL needs an injection seam the command doesn't have, and adding one for this seemed disproportionate. So this one is verified by reading only — I have not run project view --web -f json against a real server.
|
@YuKitsune this is mostly expanding data that's returned, no breaking changes. |
Both commands returned far less than the REST API does. list and view now carry the project group, lifecycle, slug, space, disabled state and tenanted deployment mode, and view additionally carries the process, variable set, library variable sets, release settings, connectivity policy and templates. Group and lifecycle IDs resolve to names the way channel list resolves lifecycles: two GetAll lookups for the whole list rather than one per project, best-effort, falling back to the ID when a name can't be resolved. Existing JSON fields keep their names and their presence, so scripts parsing the current output are unaffected. Refs #491 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
versionControlBranch used a bare type assertion on project.PersistenceSettings. IsVersionControlled and PersistenceSettings are independent fields on the wire, and the SDK leaves PersistenceSettings as a nil interface when the block is absent, so a project reporting IsVersionControlled: true without Git-typed settings crashed the command. Use a comma-ok assertion and fall back to an empty branch. Added a regression test that reproduces the panic on the previous code. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
project list fetched all lifecycles and all project groups before printing, but the basic mapper only emits the project name, so scripting callers paid two extra /all round trips for data that was never rendered. Extract the format resolution PrintArray and PrintResource already do into output.ResolveOutputFormat, and use it to skip the lookups for basic output. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
pkg/cmd/project/shared.GetLifecycleMap was a byte-for-byte copy of getLifecycleMap in pkg/cmd/channel/list, so the two would drift as soon as either grew pagination or error reporting. Move the lifecycle and project group lookups, plus the ID fallback helper, into pkg/lookups so channel commands can use them without importing a project command's shared package, and delete the channel copy. project/shared keeps TenantedDeploymentMode, which is project specific. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
DisplayName returns "" when both the resolved name and the ID are empty, which rendered as a bare "Project group: " / "Lifecycle: " line. Skip the line instead. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
browser.OpenURL lived inside the Basic formatter, so `octopus project view X --web -f table` and `-f json` printed the URL but never opened anything. Hoist the flag check into viewRun. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
versionControlBranch returns "" when a project claims to be version controlled but carries no Git settings, which rendered a bare "Version control branch: " line. Skip it, as the project group and lifecycle labels already do. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
c72af0e to
f515225
Compare
There was a problem hiding this comment.
The GetMap methods don't make it clear which is the key and which is the value. I'd like to see it either called out in the name (i.e. GetLifecycleIdToNameMap) or use types instead (i.e. func GetLifecycleMap(octopus *client.Client) map[LifecycleId]LifecycleName {)
There are also some comments in here that are just noise, or could easily go stale. Have left some suggestions, but feel free to take em' or leave em'.
| // Two lookups for the whole list rather than one per project, and best-effort | ||
| // as channel list is: listing still works without access to either. Basic | ||
| // output only prints names, so don't pay for the round trips there. |
There was a problem hiding this comment.
There's a lot of detail in this comment, and it's not exactly clear what's important about it. I'd suggest simplifying or removing.
There was a problem hiding this comment.
Trimmed in cdae0ec, down to the one thing the code cannot say:
// Basic output only prints names, so don't pay for the lookups there
var lifecycleIdToNameMap, projectGroupIdToNameMap map[string]stringThe two lines I dropped were both restating the code — that it is two calls for the whole list rather than one per project, and that the lookups are best-effort. The first is visible from the call site and the second now lives on the lookup functions' own doc comments, which is where you asked for it.
| // GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a | ||
| // failed lookup yields an empty map and callers fall back to the ID. | ||
| func GetLifecycleMap(octopus *client.Client) map[string]string { |
There was a problem hiding this comment.
| // GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a | |
| // failed lookup yields an empty map and callers fall back to the ID. | |
| func GetLifecycleMap(octopus *client.Client) map[string]string { | |
| // GetLifecycleIdToNameMap resolves lifecycle IDs to names for display. | |
| // If the name cannot be resolved, the caller should fall back to the ID. | |
| func GetLifecycleIdToNameMap(octopus *client.Client) map[string]string { |
There was a problem hiding this comment.
Applied in 4b84883, along with the other three doc comments and the local it is assigned to.
| // GetProjectGroupMap resolves project group IDs to names for display. Best-effort, | ||
| // as GetLifecycleMap is. | ||
| func GetProjectGroupMap(octopus *client.Client) map[string]string { |
There was a problem hiding this comment.
| // GetProjectGroupMap resolves project group IDs to names for display. Best-effort, | |
| // as GetLifecycleMap is. | |
| func GetProjectGroupMap(octopus *client.Client) map[string]string { | |
| // GetProjectGroupIdToNameMap resolves project group IDs to names for display. | |
| // If the name cannot be resolved, the caller should fall back to the ID. | |
| func GetProjectGroupIdToNameMap(octopus *client.Client) map[string]string { |
| // GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole | ||
| // map when only one resource is being displayed. Empty when it can't be resolved. | ||
| func GetLifecycleName(octopus *client.Client, lifecycleID string) string { |
There was a problem hiding this comment.
| // GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole | |
| // map when only one resource is being displayed. Empty when it can't be resolved. | |
| func GetLifecycleName(octopus *client.Client, lifecycleID string) string { | |
| // GetLifecycleName resolves a single lifecycle name given its ID. | |
| // An empty string is returned when the name cannot be resolved. | |
| func GetLifecycleName(octopus *client.Client, lifecycleID string) string { |
| // GetProjectGroupName resolves a single project group ID, as GetLifecycleName does. | ||
| func GetProjectGroupName(octopus *client.Client, projectGroupID string) string { |
There was a problem hiding this comment.
| // GetProjectGroupName resolves a single project group ID, as GetLifecycleName does. | |
| func GetProjectGroupName(octopus *client.Client, projectGroupID string) string { | |
| // GetProjectGroupName resolves a single project group name given its ID. | |
| // An empty string is returned when the name cannot be resolved. | |
| func GetProjectGroupName(octopus *client.Client, projectGroupID string) string { |
| // Two lookups for the whole list rather than one per project, and best-effort | ||
| // as channel list is: listing still works without access to either. Basic | ||
| // output only prints names, so don't pay for the round trips there. | ||
| var lifecycleMap, projectGroupMap map[string]string |
There was a problem hiding this comment.
| var lifecycleMap, projectGroupMap map[string]string | |
| var lifecycleIdToNameMap, projectGroupIdToNameMap map[string]string |
There was a problem hiding this comment.
Applied in 4b84883. Also renamed the four read sites in the Json and Table mappers, and pkg/cmd/channel/list picks up the renamed GetLifecycleIdToNameMap — it is the only other caller.
| lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID) | ||
| projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID) |
There was a problem hiding this comment.
| lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID) | |
| projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID) | |
| lifecycleIdToNameName := lookups.GetLifecycleName(opts.Client, project.LifecycleID) | |
| projectGroupIdToNameName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID) |
There was a problem hiding this comment.
This is the one suggestion I have not taken literally — I think it is a find/replace artifact rather than the name you meant.
These two locals hold a single resolved name, not a map, so lifecycleIdToNameName does not describe them (and GetLifecycleName / GetProjectGroupName keep their names in your other four suggestions, so the call is unchanged either way). I have left them as:
lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID)
projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID)The ID-to-name naming you asked for is applied where there actually is a map — lifecycleIdToNameMap / projectGroupIdToNameMap in project list, in 4b84883. Say the word if there is a different name you had in mind for these two and I will take it.
The comment above them is gone in cdae0ec, per your other note.
| return err | ||
| } | ||
|
|
||
| // best-effort, as channel list is: viewing still works without access to either |
There was a problem hiding this comment.
This comment isn't valuable.
| // best-effort, as channel list is: viewing still works without access to either |
| // where the project sits and how it releases; skip a label rather than print | ||
| // it with nothing after it when neither the name nor the ID is available |
There was a problem hiding this comment.
| // where the project sits and how it releases; skip a label rather than print | |
| // it with nothing after it when neither the name nor the ID is available | |
| // Skip a label rather than print it with nothing after it when neither the name nor the ID is available |
There was a problem hiding this comment.
Applied in cdae0ec, verbatim.
Shortened the sibling comment below it the same way while I was there — it was two lines making the same point about the version control branch label, now one.
GetLifecycleMap / GetProjectGroupMap did not say which side of the map was the ID and which was the name. Rename both to GetLifecycleIdToNameMap / GetProjectGroupIdToNameMap, along with the locals they are assigned to, and give all four lookups doc comments that state what is returned when the name cannot be resolved. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The comment above the project list lookups spent three lines on things the code already says; keep only the part that explains the branch. Drop the best-effort note in viewRun, which said nothing the lookups' own doc comments don't, and shorten the two label-skipping comments in the basic formatter. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Thanks — both points actioned, in two commits so they read separately. Naming (4b84883). Comments (cdae0ec). Dropped the best-effort note in One suggestion I did not take literally — the
|
Fixes #491
What changed
octopus project listandoctopus project viewreturned far less than the REST API does. Both now carry the metadata the issue asked for.project listSlug,SpaceId,ProjectGroupId,ProjectGroupName,LifecycleId,LifecycleName,IsDisabled,IsVersionControlled,TenantedDeploymentMode.SLUG,PROJECT GROUP,LIFECYCLE.xargs).project viewSpaceId,IsDisabled,ProjectGroupId/Name,LifecycleId/Name,TenantedDeploymentMode,DeploymentProcessId,VariableSetId,IncludedLibraryVariableSetIds,ClonedFromProjectId,AutoCreateRelease,DefaultGuidedFailureMode,DefaultToSkipIfAlreadyInstalled,DiscreteChannelRelease,ReleaseNotesTemplate,VersioningStrategy,ProjectConnectivityPolicy,Templates.PROJECT GROUPandLIFECYCLE; basic adds project group, lifecycle, tenanted deployment mode and enabled/disabled state.ID resolution. Group and lifecycle IDs resolve to names the way
channel listdoes since bf27551:listmakes two extraGetAllcalls for the whole listing (constant, not N+1),viewmakes twoGetByIDcalls. Both are best-effort — a permissions failure leaves the command working and the ID is displayed instead of the name, and the*NameJSON fields are omitted.Backwards compatibility. Every existing JSON field keeps its name, its type, and its presence — nothing was renamed, removed, or given
omitemptythat did not have it.Descriptionstill appears as""on a project with no description. Only additive changes. Table column sets did change (columns added, none removed), and basic output forviewgained lines;list --format basicis byte-for-byte unchanged.Is
project view -f jsonreally broken?No — not any more. The issue (Feb 2025) shows
project view -f jsonprinting the human blob, and that was real at the time. It was fixed in e6a5343 "feat: support project view -f for project view (#533)" in Aug 2025, which added theJson/Table/Basicmappers. Verified with a newoutputFormat jsontest inpkg/cmd/project/view/view_test.go. No fix needed, only enrichment.Before / after
project list -f jsonBefore:
[ { "Id": "Projects-22", "Name": "Fire Project", "Description": "", "ProjectTags": ["team/red"] } ]After:
[ { "Id": "Projects-22", "Name": "Fire Project", "Description": "", "ProjectTags": ["team/red"], "Slug": "fire-project", "SpaceId": "Spaces-1", "ProjectGroupId": "ProjectGroups-1", "ProjectGroupName": "Default Project Group", "LifecycleId": "Lifecycles-1", "LifecycleName": "Default Lifecycle", "IsDisabled": false, "IsVersionControlled": false, "TenantedDeploymentMode": "Untenanted" } ]project list -f tableBefore:
After:
(
Lifecycles-99is the fallback: that lifecycle was not in the lookup.)project list -f basicUnchanged:
project view -f jsonBefore:
{ "Id": "Projects-22", "Name": "Fire Project", "Slug": "fire-project", "Description": "Fire things", "IsVersionControlled": false, "VersionControlBranch": "Not version controlled", "ProjectTags": ["team/red"], "WebUrl": "http://server/app#/Spaces-1/projects/Projects-22" }After:
{ "Id": "Projects-22", "Name": "Fire Project", "Slug": "fire-project", "Description": "Fire things", "IsVersionControlled": false, "VersionControlBranch": "Not version controlled", "ProjectTags": ["team/red"], "WebUrl": "http://server/app#/Spaces-1/projects/Projects-22", "SpaceId": "Spaces-1", "IsDisabled": false, "ProjectGroupId": "ProjectGroups-1", "ProjectGroupName": "Default Project Group", "LifecycleId": "Lifecycles-1", "LifecycleName": "Default Lifecycle", "TenantedDeploymentMode": "Untenanted", "DeploymentProcessId": "deploymentprocess-Projects-22", "VariableSetId": "variableset-Projects-22", "IncludedLibraryVariableSetIds": ["LibraryVariableSets-1"], "AutoCreateRelease": false, "DefaultToSkipIfAlreadyInstalled": false, "DiscreteChannelRelease": false, "VersioningStrategy": { "Template": "#{Octopus.Version.LastMajor}.#{Octopus.Version.LastMinor}.#{Octopus.Version.NextPatch}" }, "ProjectConnectivityPolicy": { "AllowDeploymentsToNoTargets": false, "ExcludeUnhealthyTargets": false } }project view -f tableBefore:
After:
project view -f basicBefore:
After:
Test evidence
New
pkg/cmd/project/list/list_test.go(4 cases) andpkg/cmd/project/view/view_test.go(4 cases) — neither command had any unit tests before. Both cover table, basic and strict-JSON output, plus the lookup-failure fallback path (403 on lifecycles and project groups still renders, showing IDs).Open questions / options
Draft because these are judgement calls I would rather have decided than assumed.
1. Which fields belong in the table vs JSON only?
I put slug, project group and lifecycle in the table because they are short, high-signal, and identify a project the way a name alone doesn't.
listis now 6 columns,view8 — wide, buttarget listis already 9, so this is within house style. I deliberately keptIsDisabled,IsVersionControlledandTenantedDeploymentModeout of thelisttable; they are boolean-ish and would push it toward a wall of text.Alternative: drop
DESCRIPTIONfrom thelisttable — it is the one column that can be arbitrarily long and the only one that ever needs truncating. That is a removal, so I did not do it unilaterally.Recommendation: keep as is; revisit
DESCRIPTIONif the table gets wider again.2. Should IDs resolve to names, and what does it cost?
listcosts two extra round trips regardless of project count (Lifecycles.GetAll+ProjectGroups.GetAll), which is whatchannel listandtarget listalready do. On a space with many lifecycles those responses are not tiny, but they are two requests, not N.Alternative: resolve only in
view(2 ×GetByID) and leavelistshowing raw IDs.Recommendation: resolve in both. Raw
Lifecycles-1in a listing is not useful to a human, and the JSON keeps both the ID and the name so scripts lose nothing. Note both lookups are best-effort — a user without lifecycle or project-group read permission still gets a working listing, just with IDs.3. Always-richer output, or a
--full/--detailflag?I went with always-richer. Arguments for a flag:
view -f jsonnow emitsTemplatesandProjectConnectivityPolicy, which can be large on a real project, and the two extra API calls become opt-in.Arguments against: the issue is asking for the data to be available,
-f jsonis already the "give me everything" format, and a flag that everyone has to remember to pass is a worse default.jqhandles the extra keys for free.Recommendation: no flag. If the payload size becomes a real complaint, the cleaner lever is a
--fieldsselector across all commands rather than a project-specific--full.4.
Templatesonproject view.Included because the issue explicitly lists it. It contains
DefaultValue, which can hold aSensitiveValue— the API returns those withHasValue/Hintand no plaintext, so nothing secret is disclosed, but worth a second opinion from someone who knows that contract better than I do.5.
Project is enabledin basic view.Added unconditionally to match
tenant view, which always prints enabled/disabled. If the preference is to keep the common case tighter, printing the line only when the project is disabled is a one-line change.🤖 Generated with Claude Code